################################################################################
#
# Geometry.py - A Python module with convenience classes for dealing with 
#               geometric objects
#
# Copyright (C) 2011 Adrian Price-Whelan
# 
################################################################################

"""
todo: - Define a UnitError exception..
"""

__author__ = 'Adrian Price-Whelan <adrn@nyu.edu>'
__all__ = ["Angle", "subtends"]

# Standard library dependencies (e.g. sys, os)
from math import cos, acos, sin
import math
import copy

import convert

VALIDUNITS = ["radians", "degrees", "hours"]

class Angle(object):
    """ This class represents an Angle. Set the bounds by specifying angleObject.bounds = (lower, upper) """
    
    @staticmethod
    def fromDegrees(val): return Angle(degrees=val)
    @staticmethod
    def fromHours(val): return Angle(hours=val)
    @staticmethod
    def fromRadians(val): return Angle(radians=val)
    @staticmethod
    def fromDatetime(dt): return Angle(hours=convert.datetime2decimalTime(dt))
    @staticmethod
    def fromUnits(val, units):
        if units.lower() == 'degrees': return Angle(degrees=val)
        elif units.lower() == 'hours': return Angle(hours=val)
        elif units.lower() == 'radians': return Angle(radians=val)
        else: raise ValueError("Invalid units!")
    
    def __init__(self, degrees=None, hours=None, radians=None):
        """ Accepts an angle value in degrees. The input parameters degrees 
            and hours both accept either a string like '15:23:14.231,' or a
            decimal representation of the value, e.g. 15.387.
        """
        if degrees != None:
            self._degrees(degrees)
            self.units = "degrees"
        elif hours != None:
            self._hours(hours)
            self.units = "hours"
        elif radians != None:
            self._radians(radians)
            self.units = "radians"
        else:
            raise TypeError("When creating an Angle object, you must specify 'degrees', 'hours', or 'radians' as an input parameter.")
    
    # These are hacks so I can set the __repr__ and __str__ of the object depending 
    #   on the primary units of the Angle
    def __str__(self): return self._str()
    def __repr__(self): return self._repr()
    
    def _degrees(self, deg):
        """
        """
        if isinstance(deg, "".__class__): self.degrees = convert.parseDec(deg)
        else: self.degrees = deg
    
        self.hours = self.degrees/15.
        self.radians = math.radians(self.degrees)
    
    def _hours(self, hr):
        """
        """
        if isinstance(hr, "".__class__): self.hours = convert.parseRA(dehr)
        else: self.hours = hr
    
        self.degrees = self.hours*15.
        self.radians = math.radians(self.degrees)
    
    def _radians(self, rad):
        self.radians = rad
        self.degrees = math.degrees(self.radians)
        self.hours = self.degrees/15.
    
    def degrees__str__(self):
        return convert.dec2stringTime(self.degrees)
        
    def hours__str__(self):
        return convert.dec2stringTime(self.hours)
    
    def radians__str__(self):
        return str(self.radians)
    
    def degrees__repr__(self): return "< Angle: %f degrees -- %s >" % (self.degrees, str(self))
    def hours__repr__(self): return "< Angle: %f hours -- %s >" % (self.hours, str(self))
    def radians__repr__(self): return "< Angle: %f radians >" % self.radians
    
    def _setUnits(self, units):
        if units == 'degrees':
            self.bounds = (-180., 180.)
            self._str = self.degrees__str__
            self._repr = self.degrees__repr__
            self._value = self.degrees
        elif units == 'hours':
            self.bounds = (0., 24.)
            self._str = self.hours__str__
            self._repr = self.hours__repr__
            self._value = self.hours
        elif units == 'radians':
            self.bounds = (0., 2*math.pi)
            self._str = self.radians__str__
            self._repr = self.radians__repr__
            self._value = self.radians
        else:
            raise ValueError("Invalid units!")
    
    @property
    def units(self): return self._units
    
    @units.setter
    def units(self, val):
        lowVal = val.lower()
        if lowVal not in VALIDUNITS:
            raise ValueError("Specified units (%s) are not supported by this class!" % val)
        
        self._units = lowVal
        self._setUnits(self._units)
    
    @property
    def bounds(self): return self._bounds
    
    @bounds.setter
    def bounds(self, val):
        try:
            tupVal = tuple(val)
        except ValueError:
            print "Invalid bounds."
            raise
        
        if len(tupVal) != 2:
            raise ValueError("Invalid tuple. You have to the lower and upper bounds -- you specified %d numbers!" % len(tupVal))
        
        self._bounds = tupVal
        
    def normalized(self):
        """ Normalize the angle to be within the set bounds (self.bounds), and 
            return a new object with the normalized angle.
        """
        if self._value < self.bounds[0]:
            print self._value % self.bounds[1]
        elif self._value >= self.bounds[1]:
            if self.bounds[0] == 0:
                print self._value % self.bounds[1]
            else:
                print self._value % self.bounds[0]
        else:
            return copy.copy(self)
    
    # self._value is defined by setting the 'units' attribute. By default, it is
    #   set to whatever units you specified when you created the object.
    def __float__(self): return self._value
    def __int__(self): return int(self._value)
    def __radians__(self): return self.radians
    def __degrees__(self): return self.degrees
    
    # Addition
    def __add__(self, other, option='left'):
        selfCopy = copy.copy(self)
        otherCopy = copy.copy(other)
        
        if not isinstance(otherCopy, Angle): 
            raise TypeError("Can't add an Angle object and a %s!" % otherCopy.__class__)
        if option == 'left':
            selfCopy.units = otherCopy.units
            return Angle.fromUnits(selfCopy._value + otherCopy._value, selfCopy.units)
        elif option == 'right':
            otherCopy.units = selfCopy.units
            return Angle.fromUnits(otherCopy._value + selfCopy._value, otherCopy.units)
    def __radd__(self, other): return self.__add__(other, 'right')
    
    # Subtraction
    def __sub__(self, other, option='left'):
        if not isinstance(other, Angle): 
            raise TypeError("Can't subtract an Angle object and a %s!" % other.__class__)
        
        selfCopy = copy.copy(self)
        otherCopy = copy.copy(other)
        
        if option == 'left':
            selfCopy.units = otherCopy.units
            return Angle.fromUnits(selfCopy._value - otherCopy._value, selfCopy.units)
        elif option == 'right':
            otherCopy.units = selfCopy.units
            return Angle.fromUnits(otherCopy._value - selfCopy._value, otherCopy.units)
    def __rsub__(self, other): return self.__sub__(other, 'right')
    
    # Multiplication
    def __mul__(self, other, option='left'):
        if isinstance(other, Angle):
            raise TypeError("Multiplication is not supported between two Angle objects!")
        else:
            return Angle.fromUnits(self.degrees * other, "degrees")
    def __rmul__(self, other): return self.__mul__(other, option='right')
    
    # Division
    def __div__(self, other):
        if isinstance(other, Angle):
            raise TypeError("Division is not supported between two Angle objects!")
        else:
            return Angle.fromUnits(self.degrees / other, "degrees")
    def __rdiv__(self, other):
        if isinstance(other, Angle):
            raise TypeError("Division is not supported between two Angle objects!")
        else:
            return Angle.fromUnits(other / self.degrees, "degrees")
    
    def __truediv__(self, other):
        if isinstance(other, Angle):
            raise TypeError("Division is not supported between two Angle objects!")
        else:
            return Angle.fromUnits(self.degrees / other, "degrees")
    def __rtruediv__(self, other):
        if isinstance(other, Angle):
            raise TypeError("Division is not supported between two Angle objects!")
        else:
            return Angle.fromUnits(other / self.degrees, "degrees")
    
    # Pow
    #def __pow__(self, other):
    #    if isinstance(other, Angle):
    #        raise TypeError("'pow' is not supported between two Angle objects!")
    #    else:
    #        return Angle.fromUnits(self.degrees ** other, "degrees")
    
    def __neg__(self):
        return Angle.fromUnits(-self.degrees, "degrees")

def subtends(a1,b1,a2,b2,units="radians"):
    """ Calculate the angle subtended by 2 positions on a sphere """
    
    if units.lower() == "degrees":
        a1 = radians(a1)
        b1 = radians(b1)
        a2 = radians(a2)
        b2 = radians(b2)
    
    x1 = cos(a1)*cos(b1)
    y1 = sin(a1)*cos(b1)
    z1 = sin(b1)
    
    x2 = cos(a2)*cos(b2)
    y2 = sin(a2)*cos(b2)
    z2 = sin(b2)
    
    theta = Angle()
    theta.deg = degrees(acos(x1*x2+y1*y2+z1*z2))
    return theta # Returns an angle object

if __name__ == '__main__':
    import unittest, sys
    
    ra1 = Angle.fromHours(30)
    ra2 = Angle.fromDegrees(195)
    ra3 = Angle.fromRadians(5.*math.pi)
    
    ra1.normalized()
    ra2.normalized()
    ra3.normalized()
    
    sys.exit(0)


    class TestAngle(unittest.TestCase):
        ra1 = Angle.fromHours(9.1)
        ra2 = Angle.fromDegrees(112.3)
        
        def test_add(self):
            self.assertAlmostEqual((self.ra1+self.ra2).degrees, (self.ra2+self.ra1).degrees, 9)
            with self.assertRaises(TypeError):
                self.ra1 + 31.4
            with self.assertRaises(TypeError):
                31.4 + self.ra1
        
        def test_sub(self):
            self.assertAlmostEqual((self.ra1-self.ra2).degrees, -(self.ra2-self.ra1).degrees, 9)
            with self.assertRaises(TypeError):
                self.ra1 - 31.4
            with self.assertRaises(TypeError):
                31.4 - self.ra1
        
        def test_mul(self):
            self.assertAlmostEqual((self.ra1 * 3.4).degrees, self.ra1.degrees*3.4, 9) 
            self.assertAlmostEqual((3.4 * self.ra1).degrees, self.ra1.degrees*3.4, 9)
            with self.assertRaises(TypeError):
                self.ra1 * self.ra2
            with self.assertRaises(TypeError):
                self.ra2 * self.ra1
        
        def test_div(self):
            self.assertAlmostEqual((self.ra1 / 3.4).degrees, self.ra1.degrees/3.4, 9) 
            self.assertAlmostEqual((3.4 / self.ra1).degrees, 3.4/self.ra1.degrees, 9)
            with self.assertRaises(TypeError):
                self.ra1 / self.ra2
            with self.assertRaises(TypeError):
                self.ra2 / self.ra1
                
        def test_neg(self):
            self.assertEqual((-self.ra1).degrees, -self.ra1.degrees)
            self.assertEqual((-self.ra1).hours, -self.ra1.hours)
            
    unittest.main()